{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {
    "ein.tags": "worksheet-0",
    "slideshow": {
     "slide_type": "-"
    }
   },
   "source": [
    "# 博客文章\n",
    "\n",
    "本章要实现博客文章功能，允许用户阅读、撰写博客文章，我们关注的内容重点在 **模板重用** 、 **分页显示长列表** 以及 **富文本处理** 。\n",
    "\n",
    "\n",
    "## 提交显示博客文章\n",
    "\n",
    "先来定义文章模型`Post`：\n",
    "\n",
    "```python\n",
    "# app/models.py\n",
    "import peewee as pw\n",
    "\n",
    "\n",
    "class Post(db.Model):\n",
    "    body = pw.TextField(null=True)\n",
    "    timestamp = pw.DateTimeField(index=True, default=datetime.utcnow)\n",
    "    author = pw.ForeignKeyField(User, related_name='posts', null=True)\n",
    "\n",
    "    class Meta:\n",
    "        db_table = 'posts'\n",
    "```\n",
    "\n",
    "博客文章包含正文、时间戳以及和`User`模型之间的一对多关系。`body`字段的定义类型是`TextField`，不限制长度。\n",
    "\n",
    "在程序的首页要显示一个表单，包含一个多行文本输入框，用于输入博客文章的内容，另外还有一个提交按钮：\n",
    "\n",
    "```python\n",
    "# app/main/forms.py\n",
    "class PostForm(FlaskForm):\n",
    "    body = TextAreaField(\"What's on your mind?\", validators=[Required()])\n",
    "    submit = SubmitField('Submit')\n",
    "```\n",
    "\n",
    "`index()`视图函数处理这个表单并把以前发布的博客文章列表传给模板：\n",
    "\n",
    "```python\n",
    "# app/main/views.py\n",
    "from .forms import PostForm\n",
    "\n",
    "\n",
    "@main.route('/', methods=['GET', 'POST'])\n",
    "def index():\n",
    "    form = PostForm()\n",
    "    if (current_user.is_authenticated and\n",
    "        current_user.can(Permission.WRITE_ARTICLES) and\n",
    "            form.validate_on_submit()):\n",
    "        post = Post(body=form.body.data,\n",
    "                    author=current_user._get_current_object())\n",
    "        post.save()\n",
    "        return redirect(url_for('.index'))\n",
    "    posts = Post.select().order_by(Post.timestamp.desc())\n",
    "    return render_template('index.html', form=form, posts=posts)\n",
    "```\n",
    "\n",
    "这个视图函数把表单和完整的博客文章列表传给模板。文章列表按照时间戳进行降序排列。如果博客文章表单提交的数据能通过验证就创建一个新`Post`实例。在发布新文章之前，要检查当前用户是否有写文章的权限。\n",
    "\n",
    "新文章对象的`author`属性值为表达式`current_user._get_current_object()`。`current_user`由 Flask-Login 提供通过线程内的代理对象实现，需要真正的用户对象时 需调用`_get_current_object()`方法获取。\n",
    "\n",
    "接着对显示博客文章的首页模板进行调整：\n",
    "\n",
    "```django\n",
    "{# app/templates/index.html #}\n",
    "{% extends \"base.html\" %}\n",
    "{% import \"bootstrap/wtf.html\" as wtf %}\n",
    "\n",
    "...\n",
    "{% if current_user.is_authenticated %}\n",
    "    <div>\n",
    "        {% if current_user.can(Permission.WRITE_ARTICLES) %}\n",
    "            {{ wtf.quick_form(form) }}\n",
    "        {% endif %}\n",
    "    </div>\n",
    "{% endif %}\n",
    "<ul class=\"posts\">\n",
    "    {% for post in posts %}\n",
    "        <li class=\"post\">\n",
    "            <div class=\"post-thumbnail\">\n",
    "                <a href=\"{{ url_for('.user', username=post.author.username) }}\">\n",
    "                    <img class=\"img-rounded profile-thumbnail\" src=\"{{ post.author.avatar(size=40) }}\">\n",
    "                </a>\n",
    "            </div>\n",
    "            <div class=\"post-content\">\n",
    "                <div class=\"post-date\">{{ moment(post.timestamp).fromNow() }}</div>\n",
    "                <div class=\"post-author\"><a href=\"{{ url_for('.user', username=post.author.username) }}\">{{ post.author.username }}</a></div>\n",
    "                <div class=\"post-body\">{{ post.body }}</div>\n",
    "            </div>\n",
    "        </li>\n",
    "    {% endfor %}\n",
    "</ul>\n",
    "```\n",
    "\n",
    "如果用户所属角色没有`WRITE_ARTICLES`权限，则经`User.can()`方法检查后，不会显示博客文章表单。博客文章列表会通过 CSS 调整显示成媒体列表的样式。\n",
    "\n",
    "**🔖 执行`git checkout 11a`签出程序的这个版本。**\n",
    "\n",
    "\n",
    "## 在资料页显示博客文章\n",
    "\n",
    "将用户资料页改进一下，在上面显示该用户发布的博客文章列表。\n",
    "\n",
    "获取博客文章的资料页路由如下：\n",
    "\n",
    "```python\n",
    "# app/main/views.py\n",
    "@main.route('/user/<username>')\n",
    "def user(username):\n",
    "    user_query = User.select()\n",
    "    user = futils.get_object_or_404(user_query, (User.username == username))\n",
    "    posts = user.posts.order_by(Post.timestamp.desc())\n",
    "    return render_template('user.html', user=user, posts=posts)\n",
    "```\n",
    "\n",
    "用户发布的博客文章列表通过`User.posts`关系获取，`User.posts`返回的是查询对象，因此可在其上调用过滤器，例如`order_by()`。\n",
    "\n",
    "`user.html`模板也要使用一个HTML`<ul>`元素渲染博客文章。可以通过Jinja2 提供的`include()`指令来实现模板复用，将`index.html`和`user.html`中 相同的HTML片段移到新模板`_posts.html`中。\n",
    "\n",
    "```django\n",
    "{# app/templates/user.html #}\n",
    "...\n",
    "<h3>Posts by {{ user.username }}</h3>\n",
    "{% include '_posts.html' %}\n",
    "...\n",
    "```\n",
    "\n",
    "`_posts.html`模板名的下划线前缀是一种习惯用法，以区分独立模板和局部模板。\n",
    "\n",
    "**🔖 执行`git checkout 11b`签出程序的这个版本。**\n",
    "\n",
    "\n",
    "## 分页显示文章列表\n",
    "\n",
    "随着网站的发展，博客文章的数量会不断增多，如果要在首页和资料页显示全部文章，浏览速度会变慢且不符合实际需求。这一问题的一种解决方法是分页显示数据，进行 片段式渲染。\n",
    "\n",
    "\n",
    "### 创建虚拟博客文章数据\n",
    "\n",
    "若想实现博客文章分页，我们需要一个包含大量数据的测试数据库。这里我们使用 **ForgeryPy** 包来自动生成虚拟信息数据。\n",
    "\n",
    "严格来说，ForgeryPy 并不是这个程序的依赖，因为它只在开发过程中使用。为了区分生产环境的依赖和开发环境的依赖，可以把文件`requirements.in`换成`requirements`文件夹，在其中分别保存不同环境中的依赖。可以创建一个`dev.in`文件，列出开发过程中所需的依赖，再创建一个`prod.in`文件，列出生产环境所需的依赖。两个环境所需的大部分相同的依赖，可以创建一个`common.in`文件。这些依赖文件通过前面介绍的 pip-tools 工具进行管理，比如要生成`dev.txt`文件，可以通过 下面的命令获得：\n",
    "\n",
    "```sh\n",
    "pip-compile -o requirements/dev.txt requirements/common.in requirements/dev.in\n",
    "```\n",
    "\n",
    "下面的代码用来生成虚拟用户和博客文章：\n",
    "\n",
    "```python\n",
    "# app/models.py\n",
    "class User(UserMixin, db.Model):\n",
    "    # ...\n",
    "    @staticmethod\n",
    "    def generate_fake(count=100):\n",
    "        from random import seed\n",
    "        import forgery_py\n",
    "\n",
    "        seed()\n",
    "        fake_data = []\n",
    "        for i in range(count):\n",
    "            fake_data.append(\n",
    "                dict(email=forgery_py.internet.email_address(),\n",
    "                     username=forgery_py.internet.user_name(True),\n",
    "                     password_hash=generate_password_hash(\n",
    "                         forgery_py.lorem_ipsum.word()),\n",
    "                     confirmed=True,\n",
    "                     name=forgery_py.name.full_name(),\n",
    "                     location=forgery_py.address.city(),\n",
    "                     about_me=forgery_py.lorem_ipsum.sentence(),\n",
    "                     member_since=forgery_py.date.date(True)))\n",
    "        for idx in range(0, len(fake_data), 10):\n",
    "            with db.database.atomic():\n",
    "                User.insert_many(fake_data[idx:idx+10]).execute()\n",
    "\n",
    "\n",
    "class Post(db.Model):\n",
    "    # ...\n",
    "    @staticmethod\n",
    "    def generate_fake(count=100):\n",
    "        from random import seed, randint\n",
    "        import forgery_py\n",
    "\n",
    "        seed()\n",
    "        user_count = User.select().count()\n",
    "        fake_data = []\n",
    "        for i in range(count):\n",
    "            u = User.select().offset(randint(0, user_count - 1)).first()\n",
    "            fake_data.append(\n",
    "                dict(body=forgery_py.lorem_ipsum.sentences(randint(1, 5)),\n",
    "                     timestamp=forgery_py.date.date(True),\n",
    "                     author=u))\n",
    "        for idx in range(0, len(fake_data), 10):\n",
    "            with db.database.atomic():\n",
    "                Post.insert_many(fake_data[idx:idx+10]).execute()\n",
    "```\n",
    "\n",
    "这些虚拟对象的属性由 ForgeryPy 的随机信息生成器生成，其中的名字、电子邮件地址、 句子等属性看起来就像真的一样。\n",
    "\n",
    "用户的电子邮件地址和用户名必须是唯一的，但 ForgeryPy 随机生成这些信息，因此有重复的风险。这个异常的处理方式是使用数据库事务，每次提交10个数据，循环操作，如果中间发生异常则只影响当次事务，不会把用户写入数据库，因此生成的虚拟用户总数可能会比预期少。\n",
    "\n",
    "随机生成文章时要为每篇文章随机指定一个用户。使用`offset()`查询过滤器。这个过滤器会跳过参数中指定的记录数量。通过设定一个随机的偏移值，再调用`first()`方法，就能每次都获得一个不同的随机用户。\n",
    "\n",
    "使用新添加的方法，可以在 flask shell 中轻易生成大量虚拟用户和文章：\n",
    "\n",
    "```python\n",
    "(flaskr_env3) $ flask shell\n",
    ">>> User.generate_fake(50)\n",
    ">>> Post.generate_fake(50)\n",
    "```\n",
    "\n",
    "现在运行程序，会看到首页中显示了一个很长的随机博客文章列表。\n",
    "\n",
    "**🔖 执行`git checkout 11c`签出程序的这个版本。**\n",
    "\n",
    "\n",
    "### 分页渲染数据\n",
    "\n",
    "为了更好地处理分页数据以及后续添加分页导航，我们基于 Peewee 提供的`PaginatedQuery`并结合 Flask 实现了`Pagination`工具类，通过实例化这个类，我们就可以得到类似 Flask-SQLAlchemy 中的分页对象。下表是`Pagination`分页对象的属性：\n",
    "\n",
    "| 属性       | 说明             |\n",
    "|------------|------------------|\n",
    "| `items`    | 当前页面中的记录 |\n",
    "| `query`    | 分页的源查询     |\n",
    "| `page`     | 当前页数         |\n",
    "| `prev_num` | 上一页的页数     |\n",
    "| `next_num` | 下一页的页数     |\n",
    "| `has_next` | 如果有下一页，返回 `True` |\n",
    "| `has_prev` | 如果有上一页，返回 `True` |\n",
    "| `pages`    | 查询得到的总页数 |\n",
    "| `per_page` | 每页显示的记录数量 |\n",
    "| `total`    | 查询返回的记录总数 |\n",
    "\n",
    "分页对象上还可调用一些方法：\n",
    "\n",
    "-   `iter_pages(left_edge=2, left_current=2, right_current=5, right_edge=2)`\n",
    "\n",
    "    一个迭代器，返回一个在分页导航中显示的页数列表。这个列表的最左边显示`left_edge`页，当前页的左边显示`left_current`页，当前页的右边显示`right_current`页，最右边显示`right_edge`页。例如，在一个100页的列表中，当前页为第50页，使用默认配置，这个方法会返回以下页数:1、2、None、48、49、50、51、52、53、54、 55、None、99、100。`None`表示页数之间的间隔`next()`下一页的分页对象。\n",
    "\n",
    "-   `prev()`\n",
    "\n",
    "    上一页的分页对象\n",
    "\n",
    "-   `next()`\n",
    "\n",
    "    下一页的分页对象\n",
    "\n",
    "下面是使用`Pagination`类实现分页显示博客文章列表的视图函数：\n",
    "\n",
    "```python\n",
    "# app/main/views.py\n",
    "from utils.paginate_peewee import Pagination\n",
    "\n",
    "@main.route('/', methods=['GET', 'POST'])\n",
    "def index():\n",
    "    form = PostForm()\n",
    "    if (current_user.is_authenticated and\n",
    "        current_user.can(Permission.WRITE_ARTICLES) and\n",
    "            form.validate_on_submit()):\n",
    "        post = Post(body=form.body.data,\n",
    "                    author=current_user._get_current_object())\n",
    "        post.save()\n",
    "        return redirect(url_for('.index'))\n",
    "\n",
    "    pagination = Pagination(Post.select().order_by(Post.timestamp.desc()),\n",
    "                            current_app.config['FLASKR_POSTS_PER_PAGE'],\n",
    "                            check_bounds=False)\n",
    "    posts = pagination.items\n",
    "    return render_template('index.html', form=form, posts=posts,\n",
    "                           pagination=pagination)\n",
    "```\n",
    "\n",
    "渲染的页数将从请求的查询字符串`request.args`中获取，如果没有明确指定，则默认渲染第一页。`Pagination`第一个参数是一个查询对象（`SelectQuery`）或者模型类，`per_page`用来指定每页显示的记录数量，为了能够便利地配置每页显示的记录数量，参数`per_page`的值从程序的环境变量`FLASKR_POSTS_PER_PAGE`中读取。\n",
    "\n",
    "另一个可选参数为`check_bounds`，当其设为`True`时，如果请求的页数超出了范围，则会返回 **404** 错误；如果设为`False`（默认值），页数超出范围时会返回一个 查询对象（`SelectQuery`）。\n",
    "\n",
    "修改之后，首页中的文章列表只会显示有限数量的文章。若想查看第 2 页中的文章，要在浏览器地址栏中的 URL 后加上查询字符串`?page=2`。\n",
    "\n",
    "\n",
    "### 添加分页导航\n",
    "\n",
    "接着在模板中添加分页导航，下面是以Jinja2宏的形式实现的分页导航：\n",
    "\n",
    "```django\n",
    "{# app/templates/_macros.html #}\n",
    "{% macro pagination_widget(pagination, endpoint) %}\n",
    "    <ul class=\"pagination\">\n",
    "        <li{% if not pagination.has_prev %} class=\"disabled\"{% endif %}>\n",
    "            <a href=\"{% if pagination.has_prev %}{{ url_for(endpoint, page=pagination.prev_num, **kwargs) }}{% else %}#{% endif %}\">\n",
    "                &laquo;\n",
    "            </a>\n",
    "        </li>\n",
    "        {% for p in pagination.iter_pages() %}\n",
    "            {% if p %}\n",
    "                {% if p == pagination.page %}\n",
    "                    <li class=\"active\">\n",
    "                        <a href=\"{{ url_for(endpoint, page = p, **kwargs) }}\">{{ p }}</a>\n",
    "                    </li>\n",
    "                {% else %}\n",
    "                    <li>\n",
    "                        <a href=\"{{ url_for(endpoint, page = p, **kwargs) }}\">{{ p }}</a>\n",
    "                    </li>\n",
    "                {% endif %}\n",
    "            {% else %}\n",
    "                <li class=\"disabled\"><a href=\"#\">&hellip;</a></li>\n",
    "            {% endif %}\n",
    "        {% endfor %}\n",
    "        <li{% if not pagination.has_next %} class=\"disabled\"{% endif %}>\n",
    "            <a href=\"{% if pagination.has_next %}{{ url_for(endpoint, page=pagination.next_num, **kwargs) }}{% else %}#{% endif %}\">\n",
    "                &raquo;\n",
    "            </a>\n",
    "        </li>\n",
    "    </ul>\n",
    "{% endmacro %}\n",
    "```\n",
    "\n",
    "这个宏创建了一个 Bootstrap 分页元素，即一个有特殊样式的无序列表，其中定义了下述页 面链接。\n",
    "\n",
    "-   “上一页”链接。如果当前页是第一页，则为这个链接加上`disabled`类。\n",
    "-   分页对象的`iter_pages()`迭代器返回的所有页面链接。这些页面被渲染成具有明确页数的链接，页数在`url_for()`的参数中指定。当前显示的页面使用`active`CSS 类高亮显示。页数列表中的间隔使用省略号表示。\n",
    "-   “下一页”链接。如果当前页是最后一页，则会禁用这个链接。\n",
    "\n",
    "Jinja2 宏的参数列表中不用加入`**kwargs`即可接收关键字参数。分页宏把接收到的所有关键字参数都传给了生成分页链接的`url_for()`方法。这种方式也可在路由中使用，例如包含一个动态部分的资料页。\n",
    "\n",
    "`pagination_widget`宏可放在`index.html`和`user.html`中的`_posts.html`模板后面。\n",
    "\n",
    "下面展示其在首页中的应用：\n",
    "\n",
    "```django\n",
    "{# app/templates/index.html #}\n",
    "{% extends \"base.html\" %}\n",
    "{% import \"bootstrap/wtf.html\" as wtf %}\n",
    "{% import \"_macros.html\" as macros %}\n",
    "\n",
    "...\n",
    "{% include '_posts.html' %}\n",
    "{% if pagination and pagination.pages > 1 %}\n",
    "    <div class=\"pagination\">\n",
    "        {{ macros.pagination_widget(pagination, '.index') }}\n",
    "    </div>\n",
    "{% endif %}\n",
    "...\n",
    "```\n",
    "\n",
    "**🔖 执行`git checkout 11d`签出程序的这个版本。**\n",
    "\n",
    "\n",
    "## 富文本文章\n",
    "\n",
    "本节将对输入文章的多行文本输入框进行升级，让其支持[Markdown](http://daringfireball.net/projects/markdown/)语法，还要添加富文本文章的预览功能。\n",
    "\n",
    "要用到的包：\n",
    "\n",
    "-   **[PageDown](https://github.com/StackExchange/pagedown):** 使用 JavaScript 实现的客户端 Markdown 到 HTML 的转换程序。\n",
    "-   **[Flask-PageDown](https://github.com/miguelgrinberg/Flask-PageDown):** 为 Flask 包装的 PageDown，把 PageDown 集成到 Flask-WTF 表单中。\n",
    "-   **[Markdown](https://pythonhosted.org/Markdown/):** 使用 Python 实现的服务器端 Markdown 到 HTML 的转换程序。\n",
    "-   **[Bleach](https://bleach.readthedocs.io):** 使用 Python 实现的 HTML 清理器。\n",
    "\n",
    "将这些包添加到依赖包文件中并进行安装。\n",
    "\n",
    "\n",
    "### 预览Markdown\n",
    "\n",
    "Flask-PageDown 扩展定义了一个`PageDownField`类，其和 WTForms 中的`TextAreaField`接口 一致。先来初始化扩展：\n",
    "\n",
    "```python\n",
    "# app/__init__.py\n",
    "from flask_pagedown import PageDown\n",
    "# ...\n",
    "pagedown = Pagedown()\n",
    "# ...\n",
    "def create_app(config_name):\n",
    "    # ...\n",
    "    pagedown.init_app(app)\n",
    "    # ...\n",
    "```\n",
    "\n",
    "修改`PostForm`表单中的`body`字段，启动Markdown的文章表单：\n",
    "\n",
    "```python\n",
    "# app/main/forms.py\n",
    "from flask_pagedown.fields import PageDownField\n",
    "\n",
    "\n",
    "class PostForm(FlaskForm):\n",
    "    body = PageDownField(\"What's on your mind?\", validators=[Required()])\n",
    "    submit = SubmitField('Submit')\n",
    "```\n",
    "\n",
    "Markdown 预览使用 PageDown 库生成，Flask-PageDown 提供了一个模板宏，支持从CDN加载所需文件。下面是模板声明：\n",
    "\n",
    "```django\n",
    "{# app/templates/index.html #}\n",
    "{% block scripts %}\n",
    "\t{{ super() }}\n",
    "    {{ pagedown.include_pagedown() }}\n",
    "{% endblock %}\n",
    "```\n",
    "\n",
    "此外，还要为预览部分添加CSS样式，可参考代码仓库中的代码。\n",
    "\n",
    "**🔖 执行`git checkout 11e`签出程序的这个版本。** ⚠️ 安装程序所需的依赖。\n",
    "\n",
    "\n",
    "### 服务器端处理富文本\n",
    "\n",
    "提交表单后，POST请求只会发送纯Markdown文本，服务器上使用Markdown（使用Python编 写的Markdown到HTML转换程序）将其转换成HTML。得到HTML后，再使用Bleach进行清理，确保其中只包含几个允许使用的HTML标签。\n",
    "\n",
    "转换后的博客文章HTML代码缓存在`Post`模型的一个新字段中，在模板中可以直接调用。文章的Markdown源文本还要保存在数据库中，以防需要编辑。\n",
    "\n",
    "下面是在`Post`模型中处理Markdown文本：\n",
    "\n",
    "```python\n",
    "# app/decorators.py\n",
    "def require_instance(func):\n",
    "    @wraps(func)\n",
    "    def inner(self, *args, **kwargs):\n",
    "        if not self._get_pk_value():\n",
    "            raise TypeError(\n",
    "                \"Can't call %s with a non-instance %s\" % (\n",
    "                    func.__name__, self.__class__.__name__))\n",
    "        return func(self, *args, **kwargs)\n",
    "    return inner\n",
    "```\n",
    "\n",
    "```python\n",
    "# app/models.py\n",
    "from markdown import markdown\n",
    "import bleach\n",
    "\n",
    "from .decorators import require_instance\n",
    "\n",
    "class Post(db.Model):\n",
    "    # ...\n",
    "    body_html = pw.TextField(null=True)\n",
    "\n",
    "    # ...\n",
    "    @require_instance\n",
    "    def update_body_html(self):\n",
    "        allowed_tags = ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code',\n",
    "                        'em', 'i', 'li', 'ol', 'pre', 'strong', 'ul',\n",
    "                        'h1', 'h2', 'h3', 'p']\n",
    "        self.__class__.update(body_html=bleach.linkify(bleach.clean(\n",
    "            markdown(self.body, output_format='html'),\n",
    "            tags=allowed_tags, strip=True))).where(self._pk_expr()).execute()\n",
    "```\n",
    "\n",
    "上面的代码中使用了一个装饰器`require_instance`，用来确保更新`body_html`字段之前 相应的`Post`模型实例已经创建并存储在数据库中。\n",
    "\n",
    "将Markdown转换成HTML的过程分三步完成。\n",
    "\n",
    "1.  `markdown()`函数初步把Markdown文本转换成 HTML。\n",
    "\n",
    "2.  把得到的结果和允许使用的 HTML 标签列表传给`clean()`函数，删除所有不在白名单中的标签。\n",
    "\n",
    "3.  最后一步通过`bleach.linkify`把纯文本中的URL转换成适当的`<a>`链接<sup><a id=\"fnr.1\" class=\"footref\" href=\"#fn.1\">1</a></sup>。\n",
    "\n",
    "当插入或更新`body`字段时，先要保存实例，然后通过调用实例的`update_body_html`方法更新`body_html`字段。\n",
    "\n",
    "接着修改在模板中使用文章HTML内容的模板：\n",
    "\n",
    "```django\n",
    "{# app/templates/_posts.html #}\n",
    "...\n",
    "<div class=\"post-body\">\n",
    "    {% if post.body_html %}\n",
    "        {{ post.body_html | safe }}\n",
    "    {% else %}\n",
    "        {{ post.body }}\n",
    "    {% endif %}\n",
    "</div>\n",
    "...\n",
    "```\n",
    "\n",
    "渲染HTML格式内容时使用`| safe`后缀，其目的是告诉Jinja2不要转义HTML元素。\n",
    "\n",
    "**🔖 执行`git checkout 11f`签出程序的这个版本。** ⚠️ 此版本包含数据库迁移和安装依赖包。\n",
    "\n",
    "\n",
    "## 文章固定链接\n",
    "\n",
    "为每篇文章添加一个专页，使用唯一的 URL 引用。下面是支持固定链接功能的路由和视图函数：\n",
    "\n",
    "```python\n",
    "# app/main/views.py\n",
    "@main.route('/post/<int:id>')\n",
    "def post(id):\n",
    "    post_query = Post.select()\n",
    "    post = futils.get_object_or_404(post_query, (Post.id == id))\n",
    "    return render_template('post.html', posts=[post])\n",
    "```\n",
    "\n",
    "博客文章的URL使用插入数据库时分配的唯一`id`字段构建。\n",
    "\n",
    "⚠️ `post.html`模板接收一个列表作为参数，这个列表就是要渲染的文章。这里必须要传入列表，因为要在这个页面中复用`_posts.html`模板。\n",
    "\n",
    "固定链接添加到通用模板`_posts.html`中，显示在文章下方：\n",
    "\n",
    "```django\n",
    "{# app/templates/_posts.html #}\n",
    "\n",
    "...\n",
    "<div class=\"post-body\">\n",
    "    {% if post.body_html %}\n",
    "        {{ post.body_html | safe }}\n",
    "    {% else %}\n",
    "        {{ post.body }}\n",
    "    {% endif %}\n",
    "</div>\n",
    "<div class=\"post-footer\">\n",
    "    <a class=\"label label-info\" href=\"{{ url_for('.post', id=post.id) }}\">Permalink</a>\n",
    "</div>\n",
    "...\n",
    "```\n",
    "\n",
    "渲染固定链接页面的`post.html`模板：\n",
    "\n",
    "```django\n",
    "{# app/templates/post.html #}\n",
    "{% extends \"base.html\" %}\n",
    "\n",
    "{% block title %}Flaskr - Post{% endblock %}\n",
    "\n",
    "{% block page_content %}\n",
    "    {% include '_posts.html' %}\n",
    "{% endblock %}\n",
    "```\n",
    "\n",
    "**🔖 执行`git checkout 11g`签出程序的这个版本。**\n",
    "\n",
    "\n",
    "## 编辑文章\n",
    "\n",
    "文章编辑显示在单独的页面中，其上部会显示文章的当前版本，下面跟着一个Markdown编辑器，用于修改Markdown源，页面下部还会显示一个编辑后的文章预览。\n",
    "\n",
    "下面是编辑文章的模板：\n",
    "\n",
    "```django\n",
    "{# app/templates/edit_post.html #}\n",
    "{% extends \"base.html\" %}\n",
    "{% import \"bootstrap/wtf.html\" as wtf %}\n",
    "\n",
    "{% block title %}Flaskr - Edit Post{% endblock %}\n",
    "\n",
    "{% block page_content %}\n",
    "    <div class=\"page-header\">\n",
    "        <h1>Edit Post</h1>\n",
    "    </div>\n",
    "    <div>\n",
    "        {{ wtf.quick_form(form) }}\n",
    "    </div>\n",
    "{% endblock %}\n",
    "\n",
    "{% block scripts %}\n",
    "    {{ super() }}\n",
    "    {{ pagedown.include_pagedown() }}\n",
    "{% endblock %}\n",
    "```\n",
    "\n",
    "编辑文章的路由如下：\n",
    "\n",
    "```python\n",
    "# app/main/views.py\n",
    "@main.route('/edit/<int:id>', methods=['GET', 'POST'])\n",
    "@login_required\n",
    "def edit(id):\n",
    "    post_query = Post.select()\n",
    "    post = futils.get_object_or_404(post_query, (Post.id == id))\n",
    "    if current_user != post.author and \\\n",
    "       not current_user.can(Permission.ADMINISTER):\n",
    "        abort(403)\n",
    "    form = PostForm()\n",
    "    if form.validate_on_submit():\n",
    "        post.body = form.body.data\n",
    "        post.save()\n",
    "        post.update_body_html()\n",
    "        flash('The post has been updated.')\n",
    "        return redirect(url_for('.post', id=post.id))\n",
    "    form.body.data = post.body\n",
    "    return render_template('edit_post.html', form=form)\n",
    "```\n",
    "\n",
    "这个视图函数只允许博客文章的作者编辑文章，但管理员能编辑所有用户的文章。如果用户试图编辑其他用户的文章，视图函数会返回 **403** 错误。这里使用的`PostForm`表单类和首页中使用的是同一个。\n",
    "\n",
    "另外，为了功能完整，可在每篇博客文章的下面、固定链接的旁边添加一个指向编辑页面的链接。\n",
    "\n",
    "**🔖 执行`git checkout 11h`签出程序的这个版本。**\n",
    "\n",
    "## 脚注\n",
    "\n",
    "<sup><a id=\"fn.1\" class=\"footnum\" href=\"#fnr.1\">1</a></sup> Markdown规范没有为自动生成链接提供官方支持，PageDown以扩展的形式实现了这个功能，在服务器上要调用 `linkify()` 函数。"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "name": "python3"
  },
  "name": "11-blog-posts.ipynb"
 },
 "nbformat": 4,
 "nbformat_minor": 2
}
